<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <title>实现一个简易录音机</title>
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
    <link rel="stylesheet"
        href="https://fonts.googleapis.com/css2?family=Asap+Condensed:wght@300;400&amp;display=swap" />
    <style>
        * {
            border: 0;
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }

        :root {
            --hue: 223;
            --bg: hsl(var(--hue), 10%, 70%);
            --fg: hsl(var(--hue), 10%, 10%);
            --focus: hsl(var(--hue), 90%, 60%);
            --focus-t: hsla(var(--hue), 90%, 60%, 0);
            --red-orange: hsl(11, 90%, 50%);
            --orange: hsl(21, 90%, 50%);
            --yellow-orange: hsl(31, 90%, 50%);
            --blue: hsl(198, 90%, 50%);
            --purple: hsl(287, 40%, 70%);
            --trans-dur: 0.3s;
            font-size: calc(20px + (80 - 20) * (100vw - 280px) / (3840 - 280));
        }

        body,
        button {
            color: var(--fg);
            font: 1em/1.5 'Asap Condensed', sans-serif;
        }

        body {
            background-color: var(--bg);
            overflow: hidden;
            height: 100vh;
            transition: background-color var(--trans-dur), color var(--trans-dur);
        }

        main {
            display: flex;
            overflow-x: hidden;
            padding: 1.5em 0;
            width: 100vw;
            height: 100vh;
        }

        .tb1 {
            --display-dim: 0.2;
            border-radius: 0.75em;
            box-shadow: 0.125em 0.125em 0.125em hsl(var(--hue), 10%, 90%) inset, -0.0625em -0.0625em 0.1875em hsl(var(--hue), 10%, 50%) inset, 0 0 0.5em rgba(0, 0, 0, 0.5);
            margin: auto;
            padding: 1em;
            width: 14.25em;
            height: 15.75em;
            transition: background-color var(--trans-dur), box-shadow var(--trans-dur);
        }

        .tb1,
        .tb1__cell {
            background-color: hsl(var(--hue), 10%, 80%);
        }

        .tb1__bar {
            background-color: currentColor;
            height: 0.125em;
            opacity: var(--display-dim);
            transition: opacity calc(var(--trans-dur) / 2);
        }

        .tb1__bar--blue {
            background-color: var(--blue);
        }

        .tb1__bar--purple {
            background-color: var(--purple);
        }

        .tb1__bar--on {
            opacity: 1;
        }

        .tb1__bar--tall {
            height: 0.375em;
        }

        .tb1__bar--25p {
            width: 25%;
        }

        .tb1__bar--30p {
            width: 30%;
        }

        .tb1__bar--35p {
            width: 35%;
        }

        .tb1__bar--40p {
            width: 40%;
        }

        .tb1__bar--45p {
            width: 45%;
        }

        .tb1__bar--50p {
            width: 50%;
        }

        .tb1__bar--55p {
            width: 55%;
        }

        .tb1__bar--60p {
            width: 60%;
        }

        .tb1__bar--65p {
            width: 65%;
        }

        .tb1__bar--70p {
            width: 70%;
        }

        .tb1__bars {
            display: flex;
            gap: 0.125em;
            flex-direction: column-reverse;
        }

        .tb1__bars+.tb1__bars {
            align-items: flex-end;
        }

        .tb1__button,
        .tb1__dial {
            border-radius: 50%;
            margin: auto;
            width: 2.25em;
            height: 2.25em;
        }

        .tb1__button,
        .tb1__dial-control {
            display: flex;
        }

        .tb1__button {
            box-shadow: 0.0625em 0 0.125em hsl(var(--hue), 10%, 70%) inset, -0.0625em 0 0.125em hsl(var(--hue), 10%, 90%) inset, 0.0625em 0 0.125em hsl(var(--hue), 10%, 85%), -0.25em 0 0.375em rgba(0, 0, 0, 0.4);
            transition: background-color var(--trans-dur), box-shadow var(--trans-dur), color var(--trans-dur);
        }

        .tb1__cell {
            border-radius: 0.25em;
            box-shadow: 0 0 0 0.125em var(--focus-t), 0.0625em 0.0625em 0.0625em hsl(var(--hue), 10%, 90%) inset, -0.0625em -0.0625em 0.0625em hsl(var(--hue), 10%, 50%) inset;
            display: flex;
            transition: background-color var(--trans-dur), box-shadow var(--trans-dur), transform calc(var(--trans-dur) / 3) cubic-bezier(0.65, 0, 0.35, 1);
        }

        .tb1__cell[type='button'] {
            cursor: pointer;
        }

        .tb1__cell[type='button']:active {
            transform: scale(0.95);
        }

        .tb1__cell[type='button']:focus-visible {
            box-shadow: 0 0 0 0.125em var(--focus), 0.0625em 0.0625em 0.0625em hsl(var(--hue), 10%, 90%) inset, -0.0625em -0.0625em 0.0625em hsl(var(--hue), 10%, 50%) inset;
        }

        .tb1__cell--display {
            background-image: linear-gradient(80deg, hsl(var(--hue), 10%, 15%) 49%, hsla(var(--hue), 10%, 15%, 0) 50%), linear-gradient(hsl(var(--hue), 10%, 25%), hsl(var(--hue), 10%, 30%));
            box-shadow: 0 0 0.125em hsl(var(--hue), 10%, 30%) inset !important;
            display: grid;
            grid-column: 3;
            grid-row-start: 2;
            grid-row-end: 4;
            grid-template-columns: repeat(2, 1fr);
            grid-template-rows: 0.75em 1fr;
            padding: 0.5em;
            text-transform: uppercase;
        }

        .tb1__cell--speakers {
            display: grid;
            grid-column-start: 1;
            grid-column-end: 4;
            grid-template-columns: repeat(15, 0.625em);
            grid-template-rows: repeat(7, 0.625em);
            justify-content: center;
            align-content: center;
            place-items: center;
        }

        .tb1__dial {
            box-shadow: 0.0625em 0 0.125em hsl(var(--hue), 10%, 70%) inset, -0.0625em 0 0.125em hsl(var(--hue), 10%, 30%) inset, 0.0625em 0 0.0125em hsl(var(--hue), 10%, 90%), -0.25em 0 0.375em rgba(0, 0, 0, 0.4);
            transition: background-color var(--trans-dur), box-shadow var(--trans-dur), color var(--trans-dur);
        }

        .tb1__dial-control {
            --shadow-before-rotate: 0;
            --shadow-after1-x: 1;
            --shadow-after1-y: 1;
            --shadow-after2-x: -1;
            --shadow-after2-y: -1;
            background-color: transparent;
            border-radius: inherit;
            box-shadow: 0 0 0 0.125em var(--focus-t);
            cursor: grab;
            position: relative;
            transition: box-shadow var(--trans-dur);
            width: 100%;
            height: 100%;
        }

        .tb1__dial-control:active {
            cursor: grabbing;
        }

        .tb1__dial-control:focus-visible {
            box-shadow: 0 0 0 0.125em var(--focus);
        }

        .tb1__dial-control:before,
        .tb1__dial-control:after {
            background-color: var(--blue);
            content: '';
            display: block;
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 1;
        }

        .tb1__dial-control:before {
            border-radius: 50%;
            box-shadow: 0.0625em 0 0.0625em rgba(0, 0, 0, 0.3) inset, -0.0625em 0 0.0625em rgba(255, 255, 255, 0.3) inset;
            width: 1.75em;
            height: 1.75em;
            transform: translate(-50%, -50%) rotate(calc(1deg * var(--shadow-before-rotate)));
        }

        .tb1__dial-control:after {
            border-radius: 0.1875em;
            box-shadow: 2px 0 0 hsl(var(--hue), 10%, var(--shadow-after1-x)) inset, 0 2px 0 hsl(var(--hue), 10%, var(--shadow-after1-y)) inset, -2px 0 0 hsl(var(--hue), 10%, var(--shadow-after2-x)) inset, 0 -2px 0 hsl(var(--hue), 10%, var(--shadow-after2-y)) inset;
            opacity: 0.4;
            width: 0.375em;
            height: 1.25em;
        }

        .tb1__cell[type='button'],
        .tb1__dial-control {
            outline: 0;
            -webkit-appearance: none;
            appearance: none;
            -webkit-tap-highlight-color: transparent;
        }

        .tb1__display-label {
            color: white;
            display: block;
            font-size: 0.5em;
            line-height: 2;
        }

        .tb1__grid {
            background-color: hsl(var(--hue), 10%, 10%);
            border-radius: 0.25em;
            box-shadow: -0.0625em -0.0625em 0.0625em hsl(var(--hue), 10%, 90%), 0.0625em 0.0625em 0.125em hsl(var(--hue), 10%, 50%);
            display: grid;
            gap: 0.25em;
            grid-template-columns: repeat(3, 1fr);
            grid-template-rows: 1fr repeat(2, 3.75em);
            padding: 0.25em;
            width: 100%;
            height: 100%;
            transition: box-shadow var(--trans-dur);
        }

        .tb1__icon {
            display: block;
            margin: auto;
            width: 1.5em;
            height: 1.5em;
        }

        .tb1__rec {
            display: flex;
            align-items: center;
            gap: 0.375rem;
            grid-column-start: 1;
            grid-column-end: 3;
        }

        .tb1__rec-light {
            border-radius: 50%;
            box-shadow: 0 0 0 0.25rem var(--orange) inset;
            display: block;
            opacity: var(--display-dim);
            transition: opacity calc(var(--trans-dur) / 2);
            width: 0.625rem;
            height: 0.625rem;
        }

        .tb1__rec-light--on {
            opacity: 1;
        }

        .tb1__speaker-hole {
            background-color: hsl(var(--hue), 10%, 10%);
            border-radius: 50%;
            box-shadow: 0 -0.0625em 0.0625em hsl(var(--hue), 10%, 70%), 0 0.0625em 0.0625em hsl(var(--hue), 10%, 90%);
            width: 50%;
            height: 50%;
            transition: box-shadow var(--trans-dur);
        }

        .tb1__speaker-hole:first-child,
        .tb1__speaker-hole:nth-child(83) {
            grid-column: 3;
        }

        .tb1__speaker-hole:nth-child(12),
        .tb1__speaker-hole:nth-child(70) {
            grid-column: 2;
        }

        .tb1__speaker-hole:nth-child(25) {
            grid-column: 1;
        }

        .tb1__sr-only {
            position: absolute;
            overflow: hidden;
            width: 1px;
            height: 1px;
        }

        @media (prefers-color-scheme:dark) {
            :root {
                --bg: hsl(var(--hue), 10%, 20%);
                --fg: hsl(var(--hue), 10%, 90%);
            }

            .tb1 {
                box-shadow: 0.125em 0.125em 0.125em hsl(var(--hue), 10%, 40%) inset, -0.0625em -0.0625em 0.1875em hsl(var(--hue), 10%, 20%) inset, 0 0 0.5em rgba(0, 0, 0, 0.5);
            }

            .tb1,
            .tb1__cell {
                background-color: hsl(var(--hue), 10%, 30%);
            }

            .tb1__button {
                box-shadow: 0.0625em 0 0.125em hsl(var(--hue), 10%, 20%) inset, -0.0625em 0 0.125em hsl(var(--hue), 10%, 40%) inset, 0.0625em 0 0.125em hsl(var(--hue), 10%, 35%), -0.25em 0 0.375em rgba(0, 0, 0, 0.4);
            }

            .tb1__cell {
                box-shadow: 0 0 0 0.125em var(--focus-t), 0.0625em 0.0625em 0.0625em hsl(var(--hue), 10%, 40%) inset, -0.0625em -0.0625em 0.0625em hsl(var(--hue), 10%, 20%) inset;
            }

            .tb1__cell[type='button']:focus-visible {
                box-shadow: 0 0 0 0.125em var(--focus), 0.0625em 0.0625em 0.0625em hsl(var(--hue), 10%, 40%) inset, -0.0625em -0.0625em 0.0625em hsl(var(--hue), 10%, 20%) inset;
            }

            .tb1__dial {
                box-shadow: 0.0625em 0 0.125em hsl(var(--hue), 10%, 30%) inset, -0.0625em 0 0.125em hsl(var(--hue), 10%, 10%) inset, 0.0625em 0 0.0125em hsl(var(--hue), 10%, 40%), -0.25em 0 0.375em rgba(0, 0, 0, 0.4);
            }

            .tb1__grid {
                box-shadow: -0.0625em -0.0625em 0.0625em hsl(var(--hue), 10%, 40%), 0.0625em 0.0625em 0.125em hsl(var(--hue), 10%, 20%);
            }

            .tb1__speaker-hole {
                box-shadow: 0 -0.0625em 0.0625em hsl(var(--hue), 10%, 30%), 0 0.0625em 0.0625em hsl(var(--hue), 10%, 50%);
            }
        }
    </style>
</head>

<body>
    <div id="root"></div>
    <script
        type="module">import React, { StrictMode, useEffect, useRef, useState, } from 'https://esm.sh/react'; import { createRoot } from 'https://esm.sh/react-dom/client'; import gsap from 'https://esm.sh/gsap'; import { useGSAP } from 'https://esm.sh/@gsap/react?deps=react@18.3.1'; import { Draggable } from 'https://esm.sh/gsap/Draggable'; gsap.registerPlugin(useGSAP, Draggable); createRoot(document.getElementById('root')).render(React.createElement(StrictMode, null, React.createElement('main', null, React.createElement(IconSprites, null), React.createElement(TB1, null)))); function IconSprites() { const viewBox = '0 0 24 24'; return React.createElement('svg', { width: '0', height: '0', 'aria-hidden': 'true' }, React.createElement('defs', null, React.createElement('clipPath', { id: 'record-clip' }, React.createElement('circle', { cx: '12', cy: '12', r: '12' })), React.createElement('linearGradient', { id: 'record-grad', x1: '1', y1: '0', x2: '0', y2: '1' }, React.createElement('stop', { offset: '0%', stopColor: '#000' }), React.createElement('stop', { offset: '100%', stopColor: '#fff' })), React.createElement('mask', { id: 'record-mask' }, React.createElement('rect', { x: '0', y: '0', width: '24', height: '24', fill: 'url(#record-grad)', }))), React.createElement('symbol', { id: 'play', viewBox: viewBox }, React.createElement('polygon', { points: '8 6,18 12,8 18', fill: 'currentcolor', stroke: 'currentcolor', strokeLinejoin: 'round', strokeWidth: '2', })), React.createElement('symbol', { id: 'record', viewBox: viewBox }, React.createElement('g', { clipPath: 'url(#record-clip)', fill: 'none', strokeWidth: '18', }, React.createElement('circle', { cx: '12', cy: '12', r: '12', stroke: 'var(--yellow-orange)', }), React.createElement('g', { mask: 'url(#record-mask)' }, React.createElement('circle', { cx: '12', cy: '12', r: '12', stroke: 'var(--red-orange)', })))), React.createElement('symbol', { id: 'stop', viewBox: viewBox }, React.createElement('rect', { fill: 'currentcolor', rx: '2', ry: '2', x: '6', y: '6', width: '12', height: '12', }))) } function TB1() { const [playing, setPlaying] = useState(false); const [recording, setRecording] = useState(false); const [volume, setVolume] = useState(10); const volumeBars = 10; const [micLevel, setMicLevel] = useState(0); const micBars = 9; const recorder = useRef(null); const audio = useRef(null); const frameId = useRef(0); useEffect(() => { if (!recording) { const stopRecording = async () => { var _a; audio.current = await ((_a = recorder.current) === null || _a === void 0 ? void 0 : _a.stop()); setMicLevel(0); cancelAnimationFrame(frameId.current) }; stopRecording(); return } const audioCtx = new AudioContext(); let analyser; let bufferLength; let frequencyData; let waveData; const analyzeAudio = (stream) => { analyser = audioCtx.createAnalyser(); analyser.smoothingTimeConstant = 0.75; analyser.fftSize = 1024; bufferLength = analyser.frequencyBinCount; frequencyData = new Uint8Array(bufferLength); waveData = new Uint8Array(bufferLength); const source = audioCtx.createMediaStreamSource(stream); source.connect(analyser); getMicLevel() }; const getMicLevel = () => { analyser.getByteFrequencyData(frequencyData); analyser.getByteTimeDomainData(waveData); const level = Math.round((frequencyData[0] / 255) * micBars); setMicLevel(level); frameId.current = requestAnimationFrame(getMicLevel) }; const recordAudio = async () => { return await navigator.mediaDevices.getUserMedia({ audio: true, video: false }).then((stream) => new Promise((resolve) => { const mimeTypes = ['audio/mp4', 'audio/ogg']; const mimeType = mimeTypes.find((type) => MediaRecorder.isTypeSupported(type)); const recorder = new MediaRecorder(stream, { mimeType }); const chunks = []; recorder.addEventListener('dataavailable', (e) => { var _a; if (((_a = e.data) === null || _a === void 0 ? void 0 : _a.size) > 0) chunks.push(e.data) }); const start = () => recorder.start(500); const stop = async () => await new Promise((resolve) => { recorder.addEventListener('stop', () => { const blob = new Blob(chunks, { type: mimeType }); const url = URL.createObjectURL(blob); const audio = new Audio(url); const play = () => audio.play(); const stop = () => { audio.pause(); audio.currentTime = 0 }; const volumeTo = (volume) => { audio.volume = volume }; audio.addEventListener('pause', () => setPlaying(false)); audio.addEventListener('ended', () => stop()); stream.getTracks().forEach((track) => track.stop()); resolve({ blob, url, play, stop, volumeTo }) }); recorder.stop() }); resolve({ start, stop, stream }) })).catch(() => setRecording(false)) }; const startRecording = async () => { recorder.current = await recordAudio(); if (!recorder.current) return; recorder.current.start(); analyzeAudio(recorder.current.stream) }; startRecording(); return () => cancelAnimationFrame(frameId.current) }, [recording]); useEffect(() => { if (!audio.current) return; audio.current.volumeTo(volume / volumeBars) }, [volume]); async function play() { var _a; if (recording || playing) return; try { setPlaying(true); await ((_a = audio.current) === null || _a === void 0 ? void 0 : _a.play()) } catch (_b) { setPlaying(false) } } function stop() { var _a; if (recording || !playing) return; (_a = audio.current) === null || _a === void 0 ? void 0 : _a.stop() } function record() { var _a; if (!navigator.mediaDevices) { alert('Connection isn’t secure for recording!'); return } stop(); setRecording(!recording); if (recording) return; (_a = audio.current) === null || _a === void 0 ? void 0 : _a.volumeTo(volume / volumeBars) } return React.createElement('div', { className: 'tb1' }, React.createElement(TB1Grid, null, React.createElement(TB1Speakers, null), React.createElement(TB1Button, { icon: 'play', action: play }, 'Play'), React.createElement(TB1Button, { icon: 'stop', action: stop }, 'Stop'), React.createElement(TB1Display, null, React.createElement(TB1Rec, { recording: recording }), React.createElement(TB1Bars, { color: 'blue', label: 'Vol', level: volume, levels: volumeBars, }), React.createElement(TB1Bars, { color: 'purple', label: 'Mic', level: micLevel, levels: micBars, tall: true, })), React.createElement(TB1Button, { icon: 'record', action: record }, 'Record'), React.createElement(TB1Dial, { tick: volume, ticks: volumeBars, action: setVolume }, 'Volume'))) } function TB1Bars({ color, label, level = 0, levels = 0, tall }) { const bars = []; for (let l = 0; l < levels; ++l) { const _color = color ? `tb1__bar--${color}` : ''; const _tall = tall ? ' tb1__bar--tall' : ''; const _on = l < Math.round(level) ? ' tb1__bar--on' : ''; const width = !tall ? `tb1__bar--${25 + l * 5}p` : ' tb1__bar--25p'; bars.push(React.createElement('div', { key: l, className: `tb1__bar${_color}${_tall}${_on}${width}`, })) } return React.createElement('div', { className: 'tb1__bars' }, bars, React.createElement('span', { className: 'tb1__display-label' }, label)) } function TB1Button({ action, children, icon }) { return React.createElement('button', { className: 'tb1__cell', type: 'button', onClick: action }, React.createElement('div', { className: 'tb1__button' }, React.createElement('svg', { className: 'tb1__icon', width: '20px', height: '20px', 'aria-hidden': 'true', }, React.createElement('use', { href: `#${icon}` })), React.createElement('span', { className: 'tb1__sr-only' }, children))) } function TB1Dial({ action, children, tick, ticks }) { const dialRef = useRef(null); const startAngle = (tick / ticks) * 360; const [angle, setAngle] = useState(startAngle); const [isKeyDown, setIsKeydown] = useState(false); const _tick = (angle / 360) * ticks; const percent = `${Math.round((angle / 360) * 100)}%`; const toRad = Math.PI / 180; const shadowRotate = { '--shadow-before-rotate': -angle, '--shadow-after1-x': `${(Math.cos((-45 + angle) * toRad) / 2 + 0.5) * 100}%`, '--shadow-after1-y': `${(Math.cos((45 + angle) * toRad) / 2 + 0.5) * 100}%`, '--shadow-after2-x': `${(-Math.cos((-45 + angle) * toRad) / 2 + 0.5) * 100}%`, '--shadow-after2-y': `${(-Math.cos((45 + angle) * toRad) / 2 + 0.5) * 100}%`, }; useEffect(() => { action === null || action === void 0 ? void 0 : action(_tick) }, [action, _tick]); useEffect(() => { Draggable.create(dialRef.current, { type: 'rotation', bounds: { minRotation: 0, maxRotation: 360, }, onDrag: function () { setAngle(this.rotation) }, }) }, []); useGSAP(() => { if (isKeyDown) { gsap.to(dialRef.current, { rotation: angle, duration: 0.15, }) } }, [angle]); useGSAP(() => { gsap.to(dialRef.current, { rotation: startAngle, duration: 0, }) }, []); function tickDecrement() { let previousTick = _tick - 1; if (previousTick < 0) { previousTick = 0 } setAngle((previousTick / ticks) * 360) } function tickIncrement() { let nextTick = _tick + 1; if (nextTick > ticks) { nextTick = ticks } setAngle((nextTick / ticks) * 360) } function keyboardAction(e) { const increment = e.code === 'ArrowUp' || e.code === 'ArrowRight'; const decrement = e.code === 'ArrowDown' || e.code === 'ArrowLeft'; if (increment || decrement) { e.preventDefault(); setIsKeydown(true) } if (increment) { tickIncrement() } else if (decrement) { tickDecrement() } } return React.createElement('div', { className: 'tb1__cell' }, React.createElement('div', { className: 'tb1__dial' }, React.createElement('button', { className: 'tb1__dial-control', type: 'button', ref: dialRef, onKeyDown: keyboardAction, onKeyUp: () => setIsKeydown(false), 'aria-description': percent, style: shadowRotate, }, React.createElement('span', { className: 'tb1__sr-only' }, children)))) } function TB1Display({ children }) { return React.createElement('div', { className: 'tb1__cell tb1__cell--display' }, children) } function TB1Grid({ children }) { return React.createElement('div', { className: 'tb1__grid' }, children) } function TB1Rec({ recording }) { return React.createElement('div', { className: 'tb1__rec' }, React.createElement('span', { className: `tb1__rec-light${recording === true ? ' tb1__rec-light--on' : ''}`, }), React.createElement('span', { className: 'tb1__display-label' }, 'Rec')) } function TB1Speakers() { const holes = []; const holeCount = 93; for (let h = 0; h < holeCount; ++h) { holes.push(React.createElement('div', { key: h, className: 'tb1__speaker-hole', })) } return React.createElement('div', { className: 'tb1__cell tb1__cell--speakers' }, holes) }</script>
</body>

</html>